Skip to content

fix(modes): bound |n_eff| to reject spurious metal modes - #509

Open
benvial wants to merge 3 commits into
gdsfactory:mainfrom
benvial:fix/select-line-mode-index-bound
Open

benvial wants to merge 3 commits into
gdsfactory:mainfrom
benvial:fix/select-line-mode-index-bound

Conversation

@benvial

@benvial benvial commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Fixes #498.

Problem

select_line_mode picks the propagating mode with the highest Re(n_eff). When a femwell solve is asked for more than a few modes around a lossy conductor, it also returns modes confined to the metal, with |n_eff| close to |sqrt(eps_metal)| (about 2.6e4 for aluminium at 1 GHz). Some of them lose less per unit length than they advance, so the loss bound keeps them, and one is selected over the line mode. On the coupled-strip geometry from #483 this gave R′ about 1e9 off with no warning. It is also the cause of the order-2 solve with R′ off by about -2e7 that I mentioned on #483.

Change

  • propagating_modes and select_line_mode take max_index: float | None = None. When set, modes with |n_eff| > max_index are dropped. The default keeps the current selection unchanged, so existing calls behave as before.
  • New gsim.femwell.conductor_index_bound(epsilon, *, fraction=0.1) derives the bound from the permittivity passed to solve_modes, either the per-region dict or per-element values. It returns fraction times the smallest |sqrt(eps)| over regions where conduction dominates (|Im eps| > |Re eps|), counting only those whose bound is at least margin (default 10) times the largest index of the non-conducting regions. Weaker conductors, such as moderately doped silicon, are skipped with a UserWarning; when none qualifies it returns None, so the bound is never below the light line. It does not need the femwell runtime.
  • The solve_modes and select_line_mode docstrings point to it.

Usage:

eps = epsilon_by_region(mesh, stack, frequency_hz=f)
modes = solve_modes(mesh, epsilon=eps, wavelength_um=c / f * 1e6, num_modes=12, ...)
mode = select_line_mode(modes, max_index=conductor_index_bound(eps))

Why an index bound

The issue proposed either a bound on |n_eff| or a field-energy fraction in the dielectric. gsim.common.modes reads modes through n_eff alone, so it works for Palace rows, femwell modes and plain numbers. A field fraction would need fields and a region map that only femwell has. The two kinds of mode are far apart. On a quasi-TEM line over a good conductor, n_line**2 / eps_metal is about eps_d R' eps0 / (L' sigma), which does not depend on frequency and is of order 1e-7 here: the line mode has |n_eff| around 7, and the spurious modes in #498 start at 0.89 times |sqrt(eps_metal)|. The default fraction of 0.1 leaves a wide margin on both sides. For lines over doped silicon, a caller can move it with fraction=.

The bound is opt-in because select_line_mode has no material information, and changing its default would need a released warning first.

Tests

  • tests/common/test_mode_selection.py::TestIndexBound: the spurious index from select_line_mode can pick a spurious metal mode when more modes are requested #498 (23174-12017j) wins without a bound and is dropped with one; the bound applies to the magnitude and is inclusive; NoLineModeError is raised when nothing survives.
  • tests/femwell/test_adapter.py::TestConductorIndexBound: dict and per-element forms, the least conductive strong conductor sets the bound, a weak conductor (11.7-12j) alone gives None with a warning and does not lower an aluminium bound, the margin is taken against the largest dielectric index, a lossy dielectric is not a conductor, fraction and margin validation.
  • tests/femwell/test_series_resistance.py::TestManyModes: the mesh from select_line_mode can pick a spurious metal mode when more modes are requested #498 (25 nm at the strip, oxide capped at 0.5 µm), order 2, 12 modes. With the bound, R′ is within 3e-3 of the analytic DC resistance 1/(sigma W t).

)

A mode solve asked for many modes around a lossy conductor also returns
modes confined to the metal, with |n_eff| near |sqrt(eps_metal)|. Some lie
inside the loss bound, and the slowest-mode rule of select_line_mode picked
one, reading R' about 1e9 off.

Add max_index to propagating_modes and select_line_mode (default None,
selection unchanged) and gsim.femwell.conductor_index_bound, which derives
a bound from the permittivity given to solve_modes.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T18:29:46.987829Z 790c2da New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the bug Something isn't working label Nov 11, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f6f30c33e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/gsim/femwell/adapter.py Outdated
Comment on lines +259 to +262
conductors = values[np.abs(values.imag) > np.abs(values.real)]
if conductors.size == 0:
return None
return float(fraction * np.sqrt(np.abs(conductors)).min())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep weak conductors from collapsing the index bound

For a conduction-dominated but only moderately conductive region—for example, a doped-silicon element with epsilon=11.7-12j—this predicate includes it and the default calculation returns about 0.41. Passing that documented result to select_line_mode(..., max_index=...) combines it with the default min_index=1, so every possible default candidate is removed; in a metal-plus-graded-semiconductor map, the min() similarly lets one near-threshold element collapse an otherwise useful metal cutoff. Avoid returning a cutoff below the candidate floor (or require enough separation/an expected line index) rather than treating every |Im(epsilon)| > |Re(epsilon)| value as a usable bound.

AGENTS.md reference: AGENTS.md:L51-L53

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 790c2da. A conduction-dominated region now sets the bound only when fraction * |sqrt(eps)| is at least margin (default 10) times the largest index of the non-conducting regions, and never below the light line. Regions that fall short are skipped with a UserWarning, and the function returns None when none qualifies. 11.7-12j alone now gives None with a warning instead of 0.41, and next to aluminium it no longer lowers the aluminium bound (tests in TestConductorIndexBound).

@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.63%. Comparing base (6ef78bc) to head (790c2da).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #509      +/-   ##
==========================================
+ Coverage   91.61%   91.63%   +0.02%     
==========================================
  Files         204      204              
  Lines       28179    28283     +104     
  Branches     5324     5350      +26     
==========================================
+ Hits        25816    25917     +101     
  Misses       1354     1354              
- Partials     1009     1012       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Alisama20 Alisama20 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/common and tests/femwell pass on 5f6f30c. TestManyModes bites: with 12 modes and no bound the pick is 27921-1548j and R' is 3.8e7 times too high. With the bound it is within 1e-4 of 1/(sigma W t).
The Codex P2 holds. The min over every region with |Im eps| > |Re eps| lets a weak conductor set the bound. 11.7-12j gives 0.41 and select_line_mode raises NoLineModeError. 11.7-1000j gives 3.16, and with modes [5.13-4.68j, 2.5-0.01j] the 2.5 mode is picked without a warning.
|Im eps| > |Re eps| says conduction dominates, not how far |sqrt(eps)| sits above the line index, which is what the bound needs. I would let a region set the bound only when 0.1*|sqrt(eps)| clears the dielectric index by a wide margin, skip and warn otherwise, and never return a bound below min_index. A test with 11.7-12j should go with it.
For Al, Cu and W at 1 to 100 GHz the margin is 100x or more. For a 5e5 S/m film like TiN it is about 5x.
Without max_index the results match main on 20000 random mode sets.

A conduction-dominated region with |sqrt(eps)| near the line index, such
as doped silicon, collapsed the bound: 11.7-12j gave 0.41, below the light
line, and 11.7-1000j gave 3.16, dropping a 5.13-4.68j line mode. A
conductor now sets the bound only when fraction * |sqrt(eps)| reaches
margin (default 10) times the largest non-conductor index; others are
skipped with a warning.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 790c2da34e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

)
if not usable.any():
return None
return float(bounds[usable].min())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for skipped conductors before returning a cutoff

When a many-mode solve contains both Al and the skipped doped-Si value from the new test (epsilon={"al": 1-6.8e8j, "si": 11.7-1000j, "oxide": 3.9}), this return uses only Al and yields max_index ≈ 2608. A Si-confined mode near sqrt(eps_si) = 22.49-22.23j passes both the default loss bound (0.988 < 1) and this index bound, and its real index exceeds that of the 5.13-4.68j line mode, so the documented select_line_mode(..., max_index=...) flow can still select the material mode. The warning does not make the returned partial cutoff safe; either account for the skipped conductor in the cutoff or avoid returning one when such a mode can survive.

AGENTS.md reference: AGENTS.md:L51-L53

Useful? React with 👍 / 👎.

@benvial

benvial commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

Thanks @Alisama20, fixed in 790c2da along the lines you suggested.

  • A conductor sets the bound only when fraction * |sqrt(eps)| is at least margin times the largest |sqrt(eps)| of the non-conducting regions. margin is a new argument, default 10. I kept it at 10 rather than lower because the line mode itself sits 3.5 times above the oxide index on the select_line_mode can pick a spurious metal mode when more modes are requested #498 geometry, so a smaller margin could reach it. Al, Cu and W still give bounds 100 times or more above the dielectric.
  • Conductors that fall short are skipped with a UserWarning naming their permittivity. When none qualifies the function returns None, so the bound is never below the light line.
  • New tests: 11.7-12j alone returns None with a warning; aluminium next to 11.7-1000j keeps the aluminium bound and selects 5.13-4.68j over 2.5-0.01j; a conductor that clears oxide by 10 times but not silicon is skipped once silicon is present.

For a 5e5 S/m film like TiN, the bound depends on the surroundings: at 100 GHz it is 30, which passes next to oxide alone (needs 19.7) and is skipped with a warning next to silicon (needs 34). fraction and margin let a caller decide there.

@Alisama20

Copy link
Copy Markdown
Collaborator

I re-ran tests/common and tests/femwell on 790c2da and they pass (1102 passed, 2 skipped).
My two cases are fixed. 11.7-12j and 11.7-1000j alone now return None with a warning, and next to aluminium both give 2607.68 and keep the 5.13-4.68j line mode.
The second Codex point holds on the selection logic. With modes 5.13-4.68j and 22.49-22.23j and max_index 2607.68 the Si mode is picked. I did not check a real solve.
I see no safe cap from epsilon alone. A cap tied to the skipped region (0.45 of its index) drops the line mode at 11.7-12j.
I would say in the warning and the docstring that the returned bound does not cover modes inside skipped regions. Or return None when anything is skipped.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

select_line_mode can pick a spurious metal mode when more modes are requested

2 participants